Skip to content

fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath - #137

Open
Hathor875 wants to merge 1 commit into
Euro-Office:mainfrom
Hathor875:fix/shortenpath-normalization
Open

fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath#137
Hathor875 wants to merge 1 commit into
Euro-Office:mainfrom
Hathor875:fix/shortenpath-normalization

Conversation

@Hathor875

@Hathor875 Hathor875 commented Aug 25, 2026

Copy link
Copy Markdown

#137 — path normalization

Own find, not from a report.

Bug

NSSystemPath::ShortenPath handles a trailing .. in a post-loop branch that
had the operator inverted (== where the in-loop branch uses !=), so the two
cases were swapped:

top of stack correct actual symptom
normal segment pop pushed a/.. unresolved
.. push popped ../..""

Only paths without a trailing separator reach that branch — hence a/../ worked
and a/.. didn't.

Same function: pop_back() on an empty string when the assembly loop breaks on
the first entry. UB, segfault in practice.

Impact

All 8 callers use the result as a containment check on document-supplied paths
(ZipSlip guard in ZipUtilsCP.cpp:294, ../ rejection in CEpubFile.cpp:165,
starts_with(root) in HtmlFile2 ×2, OOXMLTags ×2, OFDFile, CImage). An empty
result passes a "starts with ../" test.

Measured over Combine(root, rel), exactly one verdict flips: .. gave
/data/.., which passed starts_with("/data") textually while pointing one
level above. Now rejected. Five other cases unchanged.

Change

DesktopEditor/common/Path.cpp, +7/−1 — one operator on line 216, one guard
before pop_back().

Tests

New DesktopEditor/common/test, first unit tests for this module. 12 cases:
3 reproduce the defects, 9 pin behaviour that must not change. Verified both
ways (fix reverted → 2 failures plus a SEGFAULT in
does_not_crash_on_bare_parent_when_removing_external_path; fix in place →
12/12), 3 identical runs. Other CTest suites still pass 6/6, including
officeutils_test, which extracts archives through ShortenPath.

Notes

  • ShortenPath(L"/") returns "" rather than /. Looks wrong, out of scope,
    deliberately not pinned.
  • EpubFile / HtmlFile2 / OFDFile have no tests — reasoning there rests on
    reading the code plus the measurement, not an executed suite.
  • Adds DesktopEditor/common/test/, same directory as fix(common): list symlinked files when scanning directories #136. Whichever lands
    second needs the two CMakeLists.txt merged by hand.

AI assistance

Prepared with AI assistance (Claude Code, claude-opus-5); commit carries an
Assisted-by: trailer.

ShortenPath handles a ".." token in two places: inside the loop, when it hits a
separator, and after the loop, for the final token. The two branches asked the
opposite question — the post-loop one tested `L".." == arStack.top()` where the
in-loop one tests `!=` — so they swapped the two cases:

  - top of stack is a normal segment: should pop (the ".." cancels it), but the
    post-loop branch pushed instead, leaving "a/.." unresolved
  - top of stack is itself "..": should push (nothing left to cancel), but the
    post-loop branch popped, so "../.." collapsed to an empty string

Only paths not ending in a separator took that branch, which is why "a/../"
worked and "a/.." did not.

All eight callers use the result as a containment check — the ZipSlip guard in
OfficeUtils, the "../" rejection in EpubFile, and starts_with(root) checks in
HtmlFile2, OFDFile and the SVG image loader — over paths taken from a document.
The empty-string case weakened the first two: an empty value passes a "starts
with ../" test. Measured against Combine(root, rel), exactly one verdict changes:
rel=".." resolved to "/data/.." and passed starts_with("/data") on a textual
prefix; it now resolves to "" and is rejected.

Also guards wsNewPath.pop_back(), which ran on an empty string when the assembly
loop breaks on the first entry (bRemoveExternalPath with a leading ".."), and
segfaulted.

Adds DesktopEditor/common/test — the first unit tests for this module. Pure
functions, no fixtures: 12 cases covering the three defects and the behaviour
that must not change. Verified by reverting the fix (2 failures plus a SEGFAULT)
and restoring it (12/12); the other built CTest suites still pass.

ShortenPath(L"/") returns an empty string rather than "/". Unchanged here and
not pinned by a test — it looks wrong but is outside this fix.

Signed-off-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com>
Assisted-by: Claude Code:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Hathor875
Hathor875 marked this pull request as ready for review August 25, 2026 20:33
@Hathor875
Hathor875 requested a review from a team as a code owner August 25, 2026 20:33
@Hathor875
Hathor875 requested review from Aiiaiiio and chrip and removed request for a team August 25, 2026 20:33

@chrip chrip left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The diagnosis is right, the two-line fix is right, and the tests are the kind this repo
should have more of. I reproduced everything independently: extracted ShortenPath into a
standalone harness in both its origin/main and post-PR forms and ran all 12 pinned cases.
Post-fix 12/12 pass; pre-fix exactly 2 fail (a/..a/.., ../.."") and
ShortenPath(L"..", true) dies under ASan with SEGV on unknown address ... caused by a WRITE memory access inside basic_string(basic_string&&) — the pop_back() on an empty
string. That matches the PR's "2 failures plus a SEGFAULT" claim precisely. The eight-caller
inventory is exact, and every one of them really is a containment check.

One substantive item before merge. The impact section measures only the starts_with(root)
form of the guard and reports "exactly one verdict flips". I swept the same change through
all the caller predicates and the three "../"-prefix guards flip 16 inputs in the
deny→allow direction
, because paths of the shape ../X/.. now resolve to exactly ".."
two characters, so a check requiring size() > 3 and a "../" prefix no longer fires. I
chased each one down to the actual file operation and none of them escape (details below), so
this is not a blocker, but the PR's central justification is a security-impact analysis and
right now it is incomplete in the one direction a reader cares about.

CI has now run — the gated workflows were released on 2026-08-27 — and it backs the PR up.
The Linux leg is green and path_test genuinely compiled, linked and executed under CTest:
Linking CXX executable path_test/path_test, then 9/9 Test #9: path_test ... Passed, with
the run ending 100% tests passed, 0 tests failed out of 8 (the shortfall is ooxml_test
being Not Run (Disabled), pre-existing). WASM is green too. The Windows leg is red, but for
a reason that has nothing to do with this PR — see below.

Verification

Claim / Item Reality Status
The two ".." branches ask opposite questions Confirmed. Path.cpp:202 (in-loop) tests L".." != arStack.top(); Path.cpp:216 (post-loop) tested L".." == arStack.top(). The in-loop form is the correct one — a ".." cancels the top only when the top is a real segment.
Only paths without a trailing separator reach the post-loop branch Confirmed by construction: the in-loop branch fires on / or \, so a trailing separator flushes the token before the loop ends. Explains a/../ working and a/.. not.
pop_back() ran on an empty string; UB, segfault in practice Confirmed. while (!arStack.empty()) breaks on the first iteration when bRemoveExternalPath && ".." == arStack.top(), leaving wsNewPath empty. Reproduced under -fsanitize=address: SEGV ... WRITE memory access in __memcpy_generic via the std::wstring move ctor. Triggered by ShortenPath(L"..", true) and ShortenPath(L"../", true) — the crash is not limited to the no-trailing-separator case, so the guard is the right shape.
Exactly 8 callers, all containment checks Exact. grep gives OOXMLTags.cpp:291 + 985, ZipUtilsCP.cpp:294, htmlfile2.cpp:1557 + 4520, CImage.cpp:79, CEpubFile.cpp:165, OFDFile/src/Utils/Utils.h:161 = 8, plus the decl/def. Read all eight: three use a "../"-prefix test, five use starts_with(root).
An empty result passes a "starts with ../" test Confirmed for all three prefix guards (ZipUtilsCP.cpp:296 and CEpubFile.cpp:167 need size() > 3; CImage.cpp:85 and the CanUseThisPath fallback need size() >= 3). And starts_with(root) on "" is false → deny, so those five fail safe.
Combine(root, rel), rel="..": /data/.."", now rejected Confirmed. Pre-fix /data/.. (passes starts_with("/data") on a textual prefix); post-fix "" (rejected).
"exactly one verdict flips; five other cases unchanged" True for the sample measured, but the sample is only the starts_with(root) callers. Across the "../"-prefix callers the change is 28 allow→deny and 16 deny→allow. See ⚠️ below. ⚠️
12 test cases: 3 reproduce defects, 9 pin existing behaviour Exact count, and the split matches. All 12 expectations verified independently against both code versions.
Guard returns "" rather than "/" for an absolute input Consistent with the pre-existing if (arStack.empty()) return std::wstring(); two lines above, which already does exactly this (that is why ShortenPath(L"/")""). Placing the new guard before the #if !defined(_WIN32) prefix block keeps the two paths in agreement rather than inventing a third convention. Right call.
NSSystemPath is part of kernel Common/CMakeLists.txt:40 compiles DesktopEditor/common/Path.cpp. The CMakeLists comment is accurate and LIBS kernel is sufficient.
add_core_gtest usage Matches the signature at common.cmake:397. GTEST_MAIN is required (no main() in path.cpp); no fixtures, so no WORKING_DIRECTORY. CORE_ROOT_DIR = ../../.. is correct for DesktopEditor/common/test. Mirrors OfficeUtils/tests/CMakeLists.txt.
KERNEL_DECL resolves to import in the test Correct — the test does not define KERNEL_USE_DYNAMIC_LIBRARY_BUILDING, which Common/CMakeLists.txt sets PRIVATE on kernel. Same as every other consumer.
Adds the same directory as #136 Confirmed, and #136 is still OPEN. Both create DesktopEditor/common/test/CMakeLists.txt (project(path_test) vs project(directory_test)) and both add a line at root CMakeLists.txt:42. Real conflict, correctly disclosed.
CI validated the change Yes, on Linux and WASM. build.yml is the only leg that sets -DEO_BUILD_TESTS=ON and runs ctest; it built path_test/path.cpp.o, linked the binary and reported Test #9: path_test ... Passed alongside the 8 existing suites. Note ctest prints case output only on failure, so the log does not enumerate the 12 cases — it evidences build + run + exit 0.
Windows leg failure is unrelated Confirmed. Build (Windows x64) dies in CMake Configure, before any core source is compiled: Common/3dParty/build_3rdparty.py failed! from depot_tools' gclient syncgit_cache.py:217 GetCachePath()FileNotFoundError: [WinError 2] (no git for depot_tools' vpython). Path.cpp is never compiled and DesktopEditor/common/test never configured, so this PR cannot cause it. Same failing step on every recent branch — including fix/doctrenderer-compile-guard (#138), which merged with it red ×3. Last green Windows run was 2026-08-15, on #132's branch.
DCO sign-off Present in the commit; DCO check SUCCESS.
AI disclosure Assisted-by: Claude Code:claude-opus-5 in both PR body and commit trailer, plus Co-Authored-By:. Compliant with the org AI policy.

Issues & Suggestions

⚠️ Major

  • The impact analysis measured one of the two guard styles, and the unmeasured one moves
    the other way.
    The PR says "Measured over Combine(root, rel), exactly one verdict
    flips". That covers the five starts_with(root) callers. It does not cover the three that
    test for a "../" prefix — ZipUtilsCP.cpp:296, CEpubFile.cpp:167, CImage.cpp:85 (and
    the wsCorePath.empty() fallback in both CanUseThisPath implementations).

    Sweeping every path over the segment alphabet {a, b, ., ..} up to length 4, with and
    without a leading/trailing separator, through those predicates gives 28 allow→deny and
    16 deny→allow. Every one of the 16 has the same shape: ../X/.. (plus .-noise
    variants like .././a/.., ../a/./..) previously resolved to ../X/.. and was rejected;
    it now correctly resolves to ".." — which is 2 characters, so size() > 3 fails and
    substr(0, 3) == "../" never matches. ".." is an escape, and the guards no longer see
    it.

    I chased all of them to the actual file operation and none escape, so I am not calling
    this blocking:

    • ZipUtilsCP.cpp: output is built from the raw filenameW (ZipUtilsCP.cpp:267), so
      the guard is the only thing in the way — but a resolved form of ".." means the write
      target ends in /.., and CreateFileW on a directory fails.
    • CEpubFile.cpp:173: sFile == ".." yields <tmp>/<content>/.., handed to
      ConvertHTML2OOXML as a file to read. A directory; the read fails.
    • CImage.cpp:88: <workdir>/.. then CFileBinary::Exists → false.

    What I would like before merge is small: pin the behaviour with one more test so nobody has
    to re-derive this, and correct the claim.

    // ../X/.. resolves to a bare "..", which is still outside the base. The "../"-prefix
    // guards in ZipUtilsCP, CEpubFile and CImage require >= 3 characters and so do not
    // catch it; only the starts_with(root) callers reject it. Pinned so the gap is visible.
    TEST(ShortenPath, collapses_a_parent_chain_to_a_bare_parent)
    {
        EXPECT_EQ(L"..", NSSystemPath::ShortenPath(L"../b/.."));
    }

    and reword the impact paragraph along the lines of: "Measured over Combine(root, rel)
    for the five starts_with(root) callers, exactly one verdict flips. The three
    "../"-prefix callers tighten in 28 cases and loosen in 16, all of the form ../X/..
    "..", which their size() >= 3 test misses; in each case the resulting path names a
    directory and the subsequent file operation fails, so no traversal materialises. Tightening
    those guards to also reject ".." and "" is left to a follow-up."

    The follow-up itself is genuinely out of scope here — worth noting that #117 already adds
    if (wsNormalizedFilename.empty()) return UNZ_INTERNALERROR;
    to ZipUtilsCP, so half of
    it lands there if that PR merges. The two PRs don't conflict (different files) but #137
    changes the semantics #117 depends on, so whichever merges second wants a re-read.

  • The red Windows check is not yours — but it should be named, not merged past silently.
    Build (Windows x64) fails in CMake Configure, in depot_tools' V8 sync
    (git_cache.py:217 GetCachePath()FileNotFoundError: [WinError 2]), before Path.cpp
    is compiled or DesktopEditor/common/test is configured. Nothing in this diff can reach
    it, and it reproduces on every recent branch. #132 (fix(v8): ensure a git shim exists in depot_tools on Windows) is the fix, and the only PR with a green Windows leg since
    2026-08-15; it was amended on 2026-08-28 and is now DCO-green with its builds re-running.
    That is a repo-health item rather than a #137 item, but until it lands this PR merges
    without Windows validation.

ℹ️ Minor

  • keeps_an_absolute_path is platform-dependent. ShortenPath only re-attaches the
    leading / under #if !defined(_WIN32) && !defined(_WIN64) (Path.cpp:249), so
    EXPECT_EQ(L"/tmp/evil.txt", ...) at path.cpp:52 returns tmp/evil.txt on Windows.
    Harmless today — build-windows.yml never sets -DEO_BUILD_TESTS=ON, so the suite isn't
    built there — but it will bite whoever turns Windows tests on. Either guard the expectation
    with the same #if, or leave a one-line comment saying the case is POSIX-only.

  • No license header on the two new files. Worth stating plainly what is and isn't a
    problem here. The good part: path.cpp does not carry a copy-pasted
    (c) Copyright Ascensio System SIA 2010-2019 block, which is the mistake I keep seeing on
    new files in these forks — it would misattribute fork-authored work and drag in the AGPL
    §7(a) and CC-BY-SA clauses that are specifically ONLYOFFICE's. Avoiding that was right.

    The gap is that there is no header at all. In this repo that is consistent with every
    precedent
    : core has zero SPDX-License-Identifier lines anywhere, and all three
    fork-authored test CMakeLists (OOXML/test, Common/cfcpp/test, OdfFile/Test/test_odf)
    and OfficeUtils/tests/main.cpp are equally bare. So this is a convention the repo has
    never set, not something this PR broke — which is exactly why it's worth setting once,
    across #136 and #137 together, rather than PR by PR. The form the org already uses in its
    own .github repo:

    /*
     * SPDX-FileCopyrightText: 2026 Euro-Office contributors
     * SPDX-License-Identifier: AGPL-3.0-or-later
     */
    

    (# for the CMakeLists.) Files that genuinely derive from Ascensio code keep the upstream
    notice and gain the Euro-Office line beneath it; only from-scratch files like these two get
    the Euro-Office header alone. Happy for this to be a separate PR if you'd rather not
    reopen #136/#137 for it.

  • TESTING.md isn't updated. That file is the register of CTest suites — it opens with
    "13 are real GoogleTest suites" and its Migration status → Done list enumerates every one.
    path_test makes 14 and is the first suite that isn't a qmake migration at all, so it
    doesn't fit the existing headings. A short "New suites (not from qmake)" bullet would keep
    the doc honest. #136 has the same gap; one entry covering both would do.

  • No linked issue. "Own find, not from a report" is fine, and the org's issue-first rule
    is aimed at features rather than drive-by bug fixes. But two things in this PR deserve to
    outlive it: ShortenPath(L"/") returning "" instead of /, and the size() >= 3 gap in
    the three prefix guards. An issue for each would stop them being rediscovered from scratch.

💡 Suggestions

  • Concrete resolution for the #136 collision. Rather than merging the two CMakeLists by
    hand into something ad hoc, whichever lands second can make the directory hold both
    targets — that scales as more DesktopEditor/common tests arrive:

    project(common_test)
    ...
    add_core_gtest(NAME path_test      SOURCES path.cpp      LIBS kernel GTEST_MAIN)
    add_core_gtest(NAME directory_test SOURCES directory.cpp LIBS kernel GTEST_MAIN)

    with a single root line add_subdirectory( "${CORE_ROOT_DIR}/DesktopEditor/common/test" common_test ).
    That also wants #136's main.cpp renamed to directory.cpp, matching this PR's choice of
    path.cppmain.cpp is a poor name for a file with no main() (GTEST_MAIN generates it).

  • The bRemoveExternalPath guard could arguably return L"/" for an absolute input instead
    of "", since /.. is /. I'd leave it as is — matching the existing arStack.empty()
    return keeps one convention instead of two, and "" fails safe under starts_with(root).
    Noting it only so the choice reads as deliberate.

Code quality & conventions

  • Commit: fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath
    Conventional Commits, correct type and scope. The body is genuinely good: it states the
    defect, the mechanism, the caller impact, the verification method, and the one thing left
    unfixed. That is the standard I'd like other PRs here to hit.
  • DCO: Signed-off-by: Krzysztof Cieślik present, check green.
  • AI disclosure: compliant — PR body section plus Assisted-by: Claude Code:claude-opus-5
    and Co-Authored-By: trailers.
  • Tests: present, and they are the right tests — pure-function cases with no fixtures, 3
    reproducing the defects and 9 pinning behaviour that must not move. Verified both ways by
    the author and independently by me. This is what a behavioural fix in this repo should look
    like.
  • Scope: focused. One function, two changes, plus the test scaffolding those changes need.
  • Comments: the block at Path.cpp:240-242 and the header comment in path.cpp both
    explain why rather than restating the code. Kept.

Verdict

Comment — the fix is correct, minimal, and properly tested; I verified the defect, the
crash, the caller inventory and all 12 expectations independently, and they all hold, and CI
now confirms path_test builds and passes under CTest. One ask before merge: extend the
impact analysis to the "../"-prefix guards — it currently reports a net tightening while 16
inputs loosen, none of them exploitable, which is one extra test and a reworded paragraph.
The red Windows leg is the pre-existing depot_tools breakage (#132), not this PR. The
license-header and TESTING.md points are conventions to settle across #136 and #137
together, not reasons to hold this PR.

Assisted-by: ClaudeCode:claude-opus-5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants